<?php
/*
 * Copyright (c) 2013 Eugene Kuzmenko
 *
 * Permission  is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in  the Software without restriction, including without limitation the rights
 * to  use,  copy,  modify,  merge, publish, distribute, sublicense, and/or sell
 * copies  of  the  Software,  and  to  permit  persons  to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The  above  copyright  notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE  SOFTWARE  IS  PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED,  INCLUDING  BUT  NOT  LIMITED  TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS  FOR  A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS  OR  COPYRIGHT  HOLDERS  BE  LIABLE  FOR  ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * For more information, see <http://www.aljey.com>.
 */

namespace Al\JS\Lib;

use Al\Core\Exception\FileNotFoundException,
    Al\Template\Lib\SourceMap,
    Al\Template\Lib\AbstractTemplateEngine,
    Al\Core\Lib\Service;

/**
 * Renders JavaScript templates. Resolves dependencies, minifies the output and
 * constructs a source map.
 *
 * @author Eugene Kuzmenko <mail@aljey.com>
 */
class JSTemplateEngine extends AbstractTemplateEngine {

  /** @inheritDoc */
  public $type = 'js';

  /**
   * @var array The micro-cache metadata array.
   */
  private $meta = array();

  /**
   * @var string The location at which to store the sprite images.
   */
  private $compiled_dir;

  /**
   * @var string Exception file name.
   */
  private $file = false;

  /**
   * Constructor
   */
  public function __construct() {
    $this->compiled_dir = __DIR__ . '/../Resources/public/map';
  }

  /** @inheritDoc */
  public function onException(\Exception $e) {
    $this->result = "throw 'Fatal: " . $e->getMessage();
    if ($this->file) {
      $this->result .= "\\nIn $this->file";
    }
    $this->result .= "';";
  }

  /**
   * Minifies and returns the result.
   *
   * {@inheritDoc}
   * @param string $params['generatedFile'] The generated source file.
   * @param string $params['sourceRoot'] [optional] Web location of the original
   *                                     sources.
   * @param string $params['prefix'] [optional] A location at which to search
   *                                 for files.
   */
  public function render($template, $params = array()) {
    if (!is_file($template)) {
      throw new FileNotFoundException($template);
    }
    $template = realpath($template);
    $hash = md5($template);
    $service = Service::get();
    $cache = $service['cache'];
    if ($this->isModified($template)) {
      $config = $service['config'];
      $minify = $service['minify']['js'];
      $map_file = $config->getPluginWebRoot('Al\JS') . "/map/$hash.map";
      if (!isset($params['plugin'])) {
        throw new \InvalidArgumentException('The plugin parameter is required');
      }
      $result = '';
      $dir = isset($params['dir']) ? trim($params['dir'], '/') : 'js';
      $sourceRoot = $config->getPluginLocation($params['plugin']) .
        "/Resources/public/$dir/";
      $sourceWebRoot = $config->getPluginWebRoot($params['plugin']) . "/$dir/";
      $map = new SourceMap($map_file, $sourceWebRoot);
      $sources = array();
      $this->resolveImports($params['plugin'], $sourceRoot, $template,
        $sources);
      $sources = array_unique($sources, SORT_REGULAR);
      $last = 0;
      $meta = &$this->getMeta();
      foreach ($sources as $source) {
        if (!in_array($source[1], $meta[$hash]['map'], true) &&
            $template !== $source[1]) {
          $meta[$hash]['map'][] = $source[1];
        }
        $originalLine = 0;
        $handle = fopen($source[1], 'r');
        $comment = false;
        while (false !== ($line = fgets($handle))) {
          $minified = preg_replace_callback('/_static\.?([\.\w]*)/',
              function ($match) {
            return "Class.getStatic('$match[1]')";
          }, preg_replace_callback('/_super(\.([^\(]+))?\(([^\)]*)\)/',
            function ($match) {
              $match[3] = empty($match[3]) ? 'arguments' : "[$match[3]]";
              return empty($match[2]) ? "this.parent.apply(this, $match[3])" :
                "this.callParent('$match[2]', $match[3])";
            }, trim($minify->pack($this->stripOpeningComment($this
              ->stripClosingComment($line, $comment), $comment)),
                " \t\n\r\0\x0B")));
          if (strlen($minified)) {
            $matches = array();
            preg_match('/^\s*/', $line, $matches);
            $result .= "$minified\n";
            $map->addMapping($source[0], $originalLine, strlen($matches[0]),
              $last++, 0);
          }
          $originalLine++;
        }
        fclose($handle);
      }
      $cache['js'] = $this->getMeta();
      $cache[$hash] = "$result//# sourceMappingURL=$map_file";
      @mkdir($this->compiled_dir, 0777, true);
      file_put_contents("$this->compiled_dir/$hash.map", $map);
    }
    $this->result = $cache[$hash];
  }

  /**
   * Returns the automatic micro-cache metadata.
   *
   * @return array The micro-cache metadata array.
   */
  private function &getMeta() {
    if (!$this->meta) $this->meta = Service::get('cache.js', array());
    return $this->meta;
  }

  /**
   * Checks whether the file or one of the files that it //='s has been
   * modified since the last check.
   *
   * @param string $filename The path to a JavaScript file.
   * @return boolean Has the file been modified?
   */
  private function isModified($filename) {
    $filename = realpath($filename);
    if (!$filename) {
      return true;
    }
    $hash = md5($filename);
    $meta = &$this->getMeta();
    if (!isset($meta[$hash])) {
      $meta[$hash] = array(
        'time' => 0,
        'map' => array(),
      );
      return true;
    }
    if (!isset($meta[$hash]['time'])) {
      $meta[$hash]['time'] = 0;
    }
    $time = filemtime($filename);
    if ($time > $meta[$hash]['time']) {
      $meta[$hash]['time'] = $time;
      return true;
    }
    if (!isset($meta[$hash]['map'])) {
      $meta[$hash]['map'] = array();
    }
    foreach ($meta[$hash]['map'] as $value) {
      if ($this->isModified($value)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Parses a JavaScript file to resolve all the //= calls.
   *
   * @param string $plugin A plugin name.
   * @param string $sourceRoot Physical location of the sources.
   * @param string $filename The path to a JavaScript file.
   * @param array $sources A collection of required file names.
   * @param string $prefix A linked location available from the source root.
   * @param string $current_plugin The current plugin being processed.
   * @param string $orig_root
   * @param int $orig_len
   * @param array $imported
   * @throws \Al\Core\Exception\FileNotFoundException
   */
  public function resolveImports($plugin, $sourceRoot, $filename,
                                 &$sources = array(), $prefix = '',
                                 $current_plugin = null, $orig_root = null,
                                 $orig_len = null, &$imported = array()) {
    $filename = realpath($filename);
    $this->file = $filename;
    $imported[] = $filename;
    $hash = md5($filename);
    $dirName = dirname($filename);
    if (!$current_plugin) $current_plugin = $plugin;
    if (!$orig_len) $orig_len = strlen($sourceRoot);
    if (!$orig_root) $orig_root = $sourceRoot;
    $meta = &$this->getMeta();
    $meta[$hash]['time'] = filemtime($filename);
    $meta[$hash]['map'] = array();
    $matches = array();
    preg_match_all('/\/\/(\?)?=\s*([^\s]+)/', file_get_contents($filename),
      $matches);
    $basename = $prefix . str_replace($sourceRoot, '', $filename);
    array_unshift($sources, array($basename, $filename));
    $i = count($matches[2]);
    $config = Service::get('config');
    while ($i--) {
      $parts = array();
      if (preg_match('/^al:\/\/([^\s]+)/', $matches[2][$i], $parts)) {
        if (!$parts[1] || (2 !== count($tplParts = explode(':', $parts[1])))) {
          throw new FileNotFoundException($parts[1]);
        }
        $dir = 'Resources/public/js';
        $plugin_name = trim($tplParts[0]);
        if (empty($plugin_name)) {
          $root = "../app/$dir";
          if (!($f = realpath("$root/$tplParts[1]")) &&
              !($f = realpath("$root/$tplParts[1].js"))) {
            throw new FileNotFoundException(
              "$tplParts[1] or $tplParts[1].js in $root");
          }
          $plugin_name = 'app_global';
        } else {
          if (!($root = $config->getPluginLocation($plugin_name))) {
            $root = '../src/' . str_replace('\\', '/', $plugin_name);
          }
          $root .= "/$dir";
          if (!($f = realpath("$root/$tplParts[1]")) &&
              !($f = realpath("$root/$tplParts[1].js"))) {
            $root = '../app/' . str_replace('\\', '', $plugin_name) . "/$dir";
            if (!($f = realpath("$root/$tplParts[1]")) &&
                !($f = realpath("$root/$tplParts[1].js"))) {
              throw new FileNotFoundException(
                "$tplParts[1] or $tplParts[1].js in $root");
            }
          }
        }
        $root = realpath($root) . '/';
        if ($plugin_name === $plugin) {
          $prefix = '';
          if (strlen($root) > $orig_len) {
            $root = substr($root, 0, $orig_len);
          }
        } else {
          if ($plugin !== $plugin_name) {
            $subdir = 'plugin/' . str_replace('\\', '', $plugin_name);
            @mkdir("$orig_root$subdir", 0777, true);
            $prefix = "$subdir/" . preg_replace('/\W/', '', $dir);
            @symlink($root, "$orig_root$prefix");
            $prefix .= '/';
          }
        }
      } else {
        if (!($f = realpath("$dirName/{$matches[2][$i]}")) &&
            !($f = realpath("$dirName/{$matches[2][$i]}.js"))) {
          throw new FileNotFoundException(
            "{$matches[2][$i]} or {$matches[2][$i]}.js in $dirName");
        }
        $prefix = '';
        $root = $sourceRoot;
        $plugin_name = $current_plugin;
        if (0 !== strpos($f, $root)) {
          $root = dirname($f) . '/';
        }
        if ($plugin !== $plugin_name) {
          if (!($start = $config->getPluginLocation($current_plugin))) {
            $start = '../src/' . str_replace('\\', '/', $current_plugin);
          }
          $subdir = 'plugin/' . str_replace('\\', '', $current_plugin);
          @mkdir("$orig_root$subdir", 0777, true);
          $prefix = "$subdir/" . preg_replace('/\W/', '',
            str_replace($start, '', $root));
          @symlink($root, "$orig_root$prefix");
          $prefix .= '/';
        }
      }
      if (!strlen($matches[1][$i]) || !in_array($f, $imported, true)) {
        $this->resolveImports($plugin, $root, $f, $sources, $prefix,
          $plugin_name, $orig_root, $orig_len, $imported);
      }
    }
  }

  /**
   * Removes opening multiline comments from JavaScript source code.
   *
   * @param string $line A line of code.
   * @param bool $comment [optional] Whether this line is inside of a comment
   *                      block.
   * @return string A line of code with multiline comments removed.
   */
  private function stripOpeningComment($line, &$comment = false) {
    $nostring = preg_replace_callback('/\"[^\"]*\"|\'[^\']*\'/',
      function ($match) {
        return (strpos($match[0], '/*') === false) ? $match[0] : '';
      }, $line);
    $matches = array();
    preg_match('/\/\*.*/', $nostring, $matches);
    if (count($matches)) {
      $comment = true;
      $replace = (strpos($matches[0], '*/') !== false) ?
        $this->stripClosingComment($matches[0], $comment, true) : $matches[0];
      $line = str_replace($replace, '', $line);
    }
    return $line;
  }

  /**
   * Removes closing multiline comments from JavaScript source code.
   *
   * @param string $line A line of code.
   * @param bool $comment [optional] Whether this line is inside of a comment
   *                      block.
   * @param bool $replace [optional] Whether to the result of the removed value
   *                      for replacement elsewhere.
   * @return string A line of code with multiline comments removed.
   */
  private function stripClosingComment($line, &$comment = false,
                                       $replace = false) {
    if ($comment) {
      $nostring = preg_replace_callback('/\"[^\"]*\"|\'[^\']*\'/',
        function ($match) {
          return (strpos($match[0], '*/') === false) ? $match[0] : '';
        }, $line);
      $matches = array();
      preg_match('/(.*)?\*\//', $nostring, $matches);
      if (count($matches)) {
        $line = str_replace($matches[0], '', $line);
        $comment = false;
        if (strpos($nostring, '/*') !== false) {
          $line = $this->stripOpeningComment($line, $comment);
        }
        if ($replace) {
          return $matches[0];
        }
      } else {
        $line = '';
      }
    }
    return $line;
  }

}